<?php
// +----------------------------------------------------------------------
// | B5Yii2CMF V3.0 [快捷通用基础管理开发平台]
// +----------------------------------------------------------------------
// | Author: 冰舞 <357145480@qq.com>
// +----------------------------------------------------------------------
declare (strict_types=1);

namespace common\extend\plugins;


use Yii;
use yii\base\Exception;
use yii\redis\Connection;

/**
 * redis缓存插件
 * 主要提供锁操作
 * 注意该类是直接设置redis，不会对键处理，Yii::$app->cache的set、get、delete都会对键进行buildKey
 *  Yii::$app->redis
 *  redis->get($key)                     获取某个键的值;    存在返回值是字符串，不存在null
 *  redis->set($key,$value,$expire)    设置某个键值，存在覆盖；  成功true，失败null
 *  redis->set($key,$value, 'NX')        设置某个键值，不存在设置，存在返回失败；    成功true，失败null
 *  redis->set($key,$value, 'EX', $expire)  设置某个有效期键值，存在覆盖；    成功true，失败null
 *  redis->set($key,$value, 'NX', 'EX', $expire)  设置某个有效期键值，不存在设置，存在返回失败；  成功true，失败null
 *  redis->del($key...)                  删除一个或多个键；返回删除的数量的字符串
 *  redis->incr($key)                  递增某个键的计数；返回计数的字符串
 *  redis->decr($key)                  递减某个键的计数；返回计数的字符串
 *  redis->exists(...$key)             判断某个键是否存在；返回存在的键的数量的字符串
 *  redis->setnx($key,$value) 等价于set($key,$value, 'NX')，不推荐使用； 成功返回"1"，失败返回 "0"
 *  redis->setex($key,$expire,$value)  等价于set($key,$value, 'EX', $expire)，不推荐使用；成功true
 */

/**
 * 根据键获取分布式redis对象，redis是必须存在的，可以指向本机
 * common\config\main.php中配置多个
 *  'components' => [
 *      'redis' => [...]
 *      'redis1' => [
 *           'class' => 'yii\redis\Connection',
 *           'hostname' => '192.168.1.151',
 *           'port' => 6379,
 *           'password' => '123456',
 *          'database' => 0,
 *       ],
 *      'redis2' => ....
 *  ]
 *
 * common/config/params.php中配置使用的redis标识数组
 * return [
 *      .............,
 *      'multi_redis' => ['redis1','redis2','redis3'],
 * ]
 */
class RedisTool
{
    private static array $_instances = [];

    public Connection $redis;


    // 根据键获取redis标识
    public static function getCode(string $key = ''): string
    {
        $list = Yii::$app->params['multi_redis'] ?? [];
        if ($list && $key) {
            $index = count($list) > 1 ? crc32(md5($key)) % count($list) : 0;
            return $list[$index];
        }
        return '';
    }

    // 获取指定redis标识的redis对象，不存在使用默认
    public static function Conn(string $code = ''): Connection
    {
        if ($code) {
            try {
                return Yii::$app->get($code);
            } catch (Exception $e) {
            }
        }
        return Yii::$app->redis;
    }

    // 根据键获取redis对象
    public static function redis(string $key = ''): Connection
    {
        return self::Conn(self::getCode($key));
    }


    // 根据键获取实例
    public static function Cluster(string $key): RedisTool
    {
        return self::Code(self::getCode($key));
    }

    // 获取本地redis的实例
    public static function Local(): RedisTool
    {
        return self::Code('');
    }

    // 根据redis标识获取实例
    public static function Code(string $code): RedisTool
    {
        if (!$code) $code = 'redis';
        if (isset(self::$_instances[$code])) return self::$_instances[$code];
        $class = new self();
        $class->redis = self::Conn($code);
        self::$_instances[$code] = $class;
        return $class;
    }

    /**
     * 获取锁，成功返回时间戳，失败返回0
     * @param string $key 键
     * @param int $expire 过期时间，没有手动unlock或超时自动释放
     * @return int
     */
    public function lock(string $key, $expire = 5): int
    {
        $value = intval(microtime(true) * 1000);
        if ($this->redis->set('lk_' . $key, $value, 'NX', 'EX', $expire)) return $value;
        return 0;
    }

    /**
     * 移除锁
     * @param string $key 键
     * @param int $version lock的返回值
     * @return string 返回移出数量
     */
    public function unlock(string $key, int $version): string
    {
        $script = <<<LUA
if redis.call('GET',KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end    
LUA;
        return $this->redis->eval($script, 1, 'lk_' . $key, $version);
    }

    /**
     * 设置某个键值, 不要让值为false null
     * @param string $key 键
     * @param $value
     * @param int $expire 缓存时间，0为永久
     * @param bool $valueSerialize 是否序列化值
     * @return bool
     */
    public function set(string $key, $value, int $expire = 0, bool $valueSerialize = true): bool
    {
        if ($expire > 0) {
            $res = $this->redis->set($key, $valueSerialize ? self::serializeValue($value) : $value, 'EX', $expire);
        } else {
            $res = $this->redis->set($key, $valueSerialize ? self::serializeValue($value) : $value);
        }
        return !!$res;
    }

    /**
     * 获取某个键，不存在返回false
     * @param string $key 键
     * @param bool $valueSerialize 值是否序列化，true对值进行反序列化
     * @return mixed 未获取到返回false
     */
    public function get(string $key, bool $valueSerialize = true): mixed
    {
        $res = $this->redis->get($key);
        if ($valueSerialize) return self::unSerializeValue($res);
        return is_null($res) ? false : $res;
    }

    /**
     * 获取并重新赋值某个键
     * @param string $key 键名
     * @param $value
     * @param bool $valueSerialize 是否对值进行序列化
     * @return mixed
     */
    public function getset(string $key, $value, bool $valueSerialize = true): mixed
    {
        if (!$valueSerialize) {
            $res = $this->redis->getSet($key, $value);
            return is_null($res) ? false : $res;
        }
        $res = $this->redis->getset($key, self::serializeValue($value));
        return self::unSerializeValue($res);
    }

    /**
     * 批量设置
     * @param array $items 批量设置项键值
     * @param int $expire 过期时间
     * @param bool $valueSerialize 是否对值进行序列化
     */
    public function mset(array $items, int $expire = 0, bool $valueSerialize = true)
    {
        if ($expire <= 0) {
            $args = [];
            foreach ($items as $key => $value) {
                $args[] = $key;
                $args[] = $valueSerialize ? self::serializeValue($value) : $value;
            }
            $this->redis->mset(...$args);
        } else {
            foreach ($items as $key => $value) {
                $this->set($key, $value, $expire, $valueSerialize);
            }
        }
    }

    public function exists(string ...$key): bool
    {
        return !!$this->redis->exists(...$key);
    }

    /**
     * 删除某个键
     * @param string ...$key
     * @return string 返回删除数量
     */
    public function delete(string ...$key): string
    {
        return $this->redis->del(...$key);
    }

    /**
     * 删除某个前缀的键
     * @param string $prefix
     * @param int $count
     * @return string
     */
    public function deleteByPrefix(string $prefix, int $count = 100): string
    {
        if (!$prefix) return '0';
        $len = 0;
        $cursor = 0;
        $match = $prefix . '*';
        do {
            $res = $this->scan($cursor, $match, $count);
            $cursor = $res[0];
            if ($res[1]) $this->redis->del(...$res[1]);
            $len += count($res[1]);
        } while ($cursor != 0);
        return (string)$len;
    }

    /**
     * redis的scan方法，默认的redis->scan无法使用
     * @param int $cursor
     * @param string $match
     * @param int $count
     * @return array
     */
    public function scan(int $cursor, string $match, int $count = 1000): array
    {
        try {
            $res = $this->redis->executeCommand('SCAN', [$cursor, 'MATCH', $match, 'COUNT', $count]);
            if ($res && is_array($res)) return [intval($res[0] ?? 0), $res[1] ?? []];
        } catch (Exception $e) {
        }
        return [0, []];
    }

    /**
     * 递增某个键的值
     * @param string $key
     * @param int $num
     * @return string 返回操作后的计数
     */
    public function incr(string $key, int $num = 1): string
    {
        if ($num > 1) return $this->redis->incrby($key, $num);
        return $this->redis->incr($key);
    }

    /**
     * 递减某个键的值
     * @param string $key
     * @param int $num
     * @return string 返回操作后的计数
     */
    public function decr(string $key, int $num = 1): string
    {
        if ($num > 1) return $this->redis->decrby($key, $num);
        return $this->redis->decr($key);
    }

    /**
     * 使用分布式锁对缓存进行防穿透
     * @param string $key 缓存key
     * @param callable $callable 回调方法，返回要缓存的数据，null不缓存
     * @param int $expire 过期时间
     * @param int $waitTime 等待时间，防止缓存时效时，并发未抢到锁的可以等待一段时间后再次获取一次
     * @param bool $valueSerialize 是否序列化
     * @param int $lockTime 锁自动释放时间
     * @return mixed
     */
    public function getOrSet(string $key, callable $callable, int $expire = 0, int $waitTime = 0, bool $valueSerialize = true, int $lockTime = 5): mixed
    {
        $res = $this->get($key, $valueSerialize);
        if ($res !== false) return $res;

        $lock = $this->lock($key, $lockTime);
        if ($lock > 1) {
            $res = call_user_func($callable);
            if (!is_null($res)) $this->set($key, $res, $expire, $valueSerialize);
            $this->unlock($key, $lock);
            return is_null($res) ? false : $res;
        } else if ($waitTime > 0) {
            // 第二次尝试，waitTime为0,防止死循环
            sleep($waitTime);
            return $this->getOrSet($key, $callable, $expire, 0, $valueSerialize, $lockTime);
        }
        return false;
    }

    // 序列化和反序列化缓存值
    public static function serializeValue($value): string
    {
        return serialize([$value]);
    }

    public static function unSerializeValue($value)
    {
        if (!$value) return false;
        $value = unserialize($value);
        return is_array($value) && isset($value[0]) ? $value[0] : false;
    }
}